iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 17

Day 17:DAG 的順序就是防線 —— runAfter 的兩條硬邊界

  • 分享至 

  • xImage
  •  

今日目的

Day 18 開始要一個一個把掃描 Task 接進 Pipeline。動手之前先把順序定下來。

Tekton 用 DAG 描述 Task 之間的依賴,runAfter 決定誰等誰。這篇要說明的是:在這條流水線裡,有兩個地方的順序不是效率選擇,是正確性條件——排錯了,掃描與簽章仍然會成功,但它們代表的意思會變。

這是設計說明,不是操作步驟。完整的 Task 實作從 Day 18 起逐篇處理。

先備知識

  • Day 12:cluster resolver,Task 從哪裡來
  • Day 13:workspace,Task 之間怎麼傳檔案
  • Day 15:image 推進 Nexus 的路徑

開場對照表

常見的做法 這條流水線的做法
能並行的全部並行,追求 CI 速度 前段盡量並行,但有兩處刻意串行
掃描跟簽章互不相干,可以同時做 簽章必須等掃描結果,否則簽章的語意會失效
推完 image 就可以先更新 GitOps 更新必須等簽章完成,否則部署會被准入控制擋掉
Task 失敗會中止整條 Pipeline Tekton 只擋「還沒開始」的下游,已經在跑的不會被中止
定期檢查 runAfter 就能確保順序防線有效 還要檢查有沒有 onError: continue——它不改 runAfter 就能繞過

第四列是前三列的成因,第 1 節有實測與上游依據。


實測環境版本表

CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
Red Hat OpenShift Pipelines Operator 1.23.1
Tekton API:Pipeline / PipelineRun / Task / TaskRun 為 tekton.dev/v1

第 1 節的失敗語意自 Tekton Pipeline upstream v0.14.0 起就是設計行為,不是這個版本的特例;本文在 1.23.1 上實測確認。

第 3 節的耗時數字與第 4 節的 Kyverno 錯誤訊息取自另一套已經跑完整條 Pipeline 的舊環境,這台重現不出來,各處都有標注。


1. Tekton 失敗時做什麼、不做什麼

這一節是後面兩條邊界的基礎。

Task 失敗時,Tekton 的處理是:

  • 阻止所有還沒開始的下游 Task
  • 不會中止已經在跑的 Task

1.1 上游怎麼說

這不是觀察到的巧合,是寫進 release note 的行為。Tekton Pipeline v0.14.0:

Task 失敗或取消時,pipeline run 停止排程新的 Task。狀態要等所有已排程的 TaskRun 完成才標記為失敗。新增 reason PipelineRunStopping,表示 pipeline run 發現失敗、正在等待 TaskRun 完成。

Stopping 這個字在 Tekton 裡語意固定。手動優雅停止的 StoppedRunFinally 定義是「已產生的 TaskRun 正常完成(含 retries),但不再排程新的非 finally task」——同一套語意。

1.2 實測

用一個拋棄式 PipelineRun 驗:fail-fast 兩秒後失敗,long-runner 與它並行跑 30 秒,downstream 排在 fail-fast 之後。

apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
  generateName: probe-failfast-
  namespace: ci
spec:
  pipelineSpec:
    tasks:
      - name: fail-fast
        taskSpec:
          steps:
            - name: fail
              image: registry.access.redhat.com/ubi9/ubi-minimal:latest
              script: |
                sleep 2
                exit 1
      - name: long-runner
        taskSpec:
          steps:
            - name: sleep
              image: registry.access.redhat.com/ubi9/ubi-minimal:latest
              script: |
                sleep 30
                echo FINISHED_ANYWAY
      - name: downstream
        runAfter: [fail-fast]
        taskSpec:
          steps:
            - name: never
              image: registry.access.redhat.com/ubi9/ubi-minimal:latest
              script: echo SHOULD_NOT_RUN

結果:

TASK          STATUS       START                  END
fail-fast     StepFailed   2026-08-14T21:11:09Z   2026-08-14T21:12:18Z
long-runner   Succeeded    2026-08-14T21:11:09Z   2026-08-14T21:12:57Z

long-runnerfail-fast 結束後又跑了 39 秒才完成,log 有 FINISHED_ANYWAYdownstream 查不到 TaskRun。

1.3 三個機器可讀的訊號

不必從行為反推,Tekton 自己在狀態裡講明了。

訊號一:狀態訊息區分 FailedCancelled

Tasks Completed: 2 (Failed: 1, Cancelled 0), Skipped: 1

Cancelled 0——沒有任何 Task 被取消。

訊號二:中間狀態的 reason 是 PipelineRunStopping 失敗之後、long-runner 還在跑的那段時間:

Unknown / PipelineRunStopping
Tasks Completed: 1 (Failed: 1, Cancelled 0), Incomplete: 1, Skipped: 1

Stopping 而不是 Cancelling,加上 Incomplete: 1

這個訊號在 pipeline 含 finally 時可能不出現(tektoncd/pipeline #3119,2020 年回報,1.23.1 修沒修未查)。上面的探測沒有 finally。之後加了 finally 就改看訊號一與訊號三,那兩個不受影響。

訊號三:skippedTasks 是獨立欄位。

& $OC --kubeconfig $kc get pipelinerun <run> -n ci -o jsonpath='{.status.skippedTasks[*].name}'
#   downstream

childReferences 裡只有 fail-fastlong-runnerdownstream 不是建了又刪,是從來沒有被建立

這個 sleep 2; exit 1 的 Task 花了 69 秒才結束——ubi-minimal 沒有快取,要先拉 image。做時序驗證時要把拉取時間算進去,不然會誤判成「失敗被延遲處理」。

這三個訊號證明的是「Tekton 不中止已經在跑的 Task」,僅此而已。 它們偵測不到 §5 前提二那個繞法——在那裡三個訊號全部正常:Cancelled 0、沒有 PipelineRunStoppingskippedTasks 是空的。

1.4 所以判斷準則是什麼

兩個並行的 Task 之間沒有任何協調。A 失敗的那一刻,B 可能已經跑完了,而 B 完全不知道 A 出了事。

所以「兩件事互不相干」不足以判斷能不能並行。要問的是:

如果其中一個失敗,另一個已經產生的結果還算不算數?

  • 算數 → 可以並行
  • 不算數 → 必須串行

後面每一節都用這把尺。


2. 前段:能並行的並行,全部匯流

前段的掃描與測試不是全部互不依賴,有兩層次序:

git-clone
  ├─► gitleaks-scan ──────────────────────────────► build-push
  ├─► sca-scan ───────────────────────────────────► build-push
  └─► npm-build
         ├─► eslint-check ────────────────────────► build-push
         └─► semgrep-scan ────────────────────────► build-push
                └─► unit-test ────────────────────► build-push
  • gitleaks-scansca-scannpm-build 直接接在 git-clone 之後,三個並行
  • eslint-checksemgrep-scan 要等 npm-build——沒有 node_modules 就沒有依賴樹可分析
  • unit-test 接在 semgrep-scan 之後

全部匯流到 build-push

- name: build-push
  runAfter: [unit-test, eslint-check, gitleaks-scan, semgrep-scan, sca-scan]

runAfter 陣列列出的全部成功,build-push 才會開始。

這裡不需要 1.4 的判斷,因為前段這些 Task 都不產生對外的產物——失敗了就是失敗了。理由比較單純:沒必要花 Registry 空間跟頻寬去存一個已知有問題的映像檔。gitleaks-scan 已經抓到程式碼裡有 API Key,繼續 build 出來只是讓這個產物多存活一段時間。


3. 硬邊界一:trivy-scancosign-sign

3.1 為什麼不能並行

Cosign 簽章的語意是「這個映像檔通過了所有關卡」。任何看到簽章的人都會據此判斷它是安全的。

套用 1.4:如果 trivy-scan 失敗,cosign-sign 已經蓋下去的章還算不算數?

不算。一個帶簽章的高風險映像檔,比一個沒簽章的高風險映像檔更危險——它會通過後面所有依賴簽章判斷信任的機制。

這是語意上的結論,跟兩者跑多久無關。

3.2 耗時差距決定這個風險有多容易踩到

trivy-scan    7~10 秒
cosign-sign   4~6 秒

即使同時起跑,cosign-sign 幾乎每次都會先跑完。trivy-scan 判定失敗的那個時間點,章十之八九已經簽好了。

這組數字取自另一套已經跑完整條 Pipeline 的舊環境,是跨多次成功執行的比對值。在你自己的環境上會不一樣——image 大小、Trivy 的資料庫更新、Registry 延遲都有影響。要量的話:

& $OC --kubeconfig $kc get taskrun -n ci -l tekton.dev/pipelineRun=<run-name> `
    -o custom-columns='TASK:.metadata.labels.tekton\.dev/pipelineTask,START:.status.startTime,END:.status.completionTime'

3.3 正確的寫法

- name: trivy-scan
  runAfter: [build-push]
- name: cosign-sign
  runAfter: [trivy-scan]

4. 硬邊界二:cosign-signupdate-gitops

4.1 為什麼不能並行

update-gitops 更新 Git 倉庫,ArgoCD 偵測到變更後開始部署新版 Pod。

如果它跟 cosign-sign 並行,可能出現這個順序:

update-gitops 完成  →  ArgoCD 開始部署  →  Kyverno 檢查簽章  →  簽章還沒產生  →  Pod 被拒絕

Kyverno 在 Pod 建立時攔截並驗證簽章,查不到就拒絕建立:

Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:

resource Pod/frontend-demo/unsigned-test-pod was blocked due to the following policies

verify-frontend-demo-image:
  verify-image-signature: 'failed to verify image …:unsigned-test:
    .attestors[0].entries[0].keys: no signatures found'

經 ArgoCD / ReplicaSet 呈現時外面可能多包一層 FailedCreate,底層擋下 Pod 的是這則訊息。

這則訊息取自舊環境。 這台目前沒有 Kyverno 也沒有 ArgoCD——全叢集 17 個 admission webhook 全是 Tekton 與 OpenShift 內建的,clusterpolicy CRD 不存在。這台重現不出來。

4.2 這是 race condition,不是穩定失敗

會不會出事取決於 cosign-signupdate-gitops 誰先完成。有時候正常、有時候失敗,是最難排查的一類問題——重跑一次可能就過了。

這條 race condition 是依 Kyverno 的驗證時機與 ArgoCD 的同步行為推導的,沒有實際觸發過:舊環境的 DAG 從一開始就把 update-gitops 排在 cosign-sign 之後。上面那則錯誤訊息來自另一次測試——故意推一個未簽章的映像檔,Kyverno 拒絕的格式相同,差別只在原因是「沒人簽」而不是「還沒簽完」。

4.3 正確的寫法

- name: update-gitops
  runAfter: [cosign-sign]

4.4 可以並行的那個

- name: upload-sbom
  runAfter: [build-push]

upload-sbomcosign-sign 並行沒問題。套用 1.4:如果 cosign-sign 失敗,已經上傳的 SBOM 還算不算數?

算。SBOM 描述的是「這個映像檔裡有什麼」,那是事實陳述,不是安全承諾,不因為簽章失敗而失效。


5. 這兩條邊界要換來什麼

強制 trivy-scan → cosign-sign → update-gitops 之後,可以得到一種間接信任:

Kyverno 驗簽通過時,等於同時確認這個映像檔通過了 Trivy。 因為依 DAG 的順序,沒通過掃描的映像檔走不到簽章那一步。Kyverno 不需要自己去查掃描報告,一個驗簽動作就帶出了前面所有關卡。

這是 SLSA 供應鏈框架的核心思路:下游驗證上游留下的證明,間接信任整條鏈路。

這一段是規劃,不是現況。 這台還沒有 Kyverno,Day 29 才會裝。在那之前,Pipeline 內部的順序就是唯一的檢查——沒有第二道防線可以兜底。這讓兩條硬邊界現在更必要,不是更不必要。

前提一:DAG 沒被改動

簽章不攜帶「我通過了掃描」這個資訊。 它只證明有人用私鑰簽了這個映像檔。

改一行 runAfter,簽章就不再代表通過掃描——而驗簽照樣會過,不會有任何地方亮紅燈

前提二:關鍵 Task 沒有被標成失敗可忽略

這條比前提一隱蔽,因為它不用動 runAfter 一個字

Tekton 支援把 pipelineTask 或 step 標成 onError: continue(1.23.1 的 enable-api-fields: beta 就有,不需要開 alpha)。

實測:在模擬的 trivy-scan 上加這一行,下游兩個 Task 照原本的 runAfter 串著、完全沒改:

PipelineRun   status  = True
              reason  = Succeeded
              message = Tasks Completed: 3 (Failed: 1 (Ignored: 1), Cancelled 0), Skipped: 0
              skippedTasks = (空)

trivy-scan      False   FailureIgnored
cosign-sign     True    Succeeded      → 章簽下去了
update-gitops   True    Succeeded      → 部署觸發了

§1.3 那三個訊號全部正常。 Cancelled 是 0、沒有 PipelineRunStoppingskippedTasks 是空的——收工清單裡「看 Cancelled 是不是 0」那一項在這裡毫無作用。

機制上到底發生了什麼

不是 runAfter 這條邊的語意被改了。

Tekton 的「上游失敗就不排下游」建立在**「那次失敗被判定為失敗」上。onError: continue 改的是上游那次執行的分類**,讓它不算失敗——於是停止邏輯根本沒有被觸發。

邊還在,只是沒有東西去撞它。

step 層更徹底:兩層都沒有痕跡

onError 也可以寫在 step 上:

steps:
  - name: scan
    onError: continue
PipelineRun   reason  = Succeeded
              message = Tasks Completed: 2 (Failed: 0, Cancelled 0), Skipped: 0
TaskRun       True / Succeeded

Failed: 0 pipelineTask 層至少還留下 (Ignored: 1)FailureIgnored,step 層在 PipelineRun 與 TaskRun 兩層都乾乾淨淨。

唯一的痕跡是:

status.steps[].terminated.exitCode = 5
status.steps[].terminated.reason   = Completed      ← 還寫著 Completed

要抓它只能逐一翻「成功的」TaskRun 裡每個 step 的 exit code。

這個繞法只對純 runAfter 有效

如果下游用 $(tasks.trivy-scan.results.verdict) 取上游的結果,整條 run 直接紅燈:

reason  = PipelineValidationFailed
message = Invalid task result reference: Could not find result with name verdict ...

上游被忽略之後不會產出 result,下游的引用就解析不了。

所以繞得過去的,正好就是第 3、4 節那個純 runAfter 的拓撲。 這不是削弱前面的論證——它說明「只用 runAfter 表達依賴」本身就是這個弱點的來源。要讓邊界更硬,可以讓 cosign-sign 去引用 trivy-scan 的 result,把「順序依賴」升級成「資料依賴」。本篇不做,但那是 §5 這個信任真正能被強化的方向。


這兩個前提都是 Pipeline 定義本身需要被保護的理由,也是 Tekton Chains 的 provenance 想解決的問題:把「用什麼流程建構的」也簽進證明裡,讓這個信任從「假設設定是對的」變成可驗證。


6. 怎麼驗證 DAG

光看 YAML 不夠,要確認「該並行的真的並行、該串行的真的串行」。

6.1 查依賴定義

$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kc = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"

& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <pipeline-name> -n ci `
    -o jsonpath="{range .spec.tasks[*]}{.name}{' <- '}{.runAfter}{'\n'}{end}"

輸出(節錄本篇相關的部分):

build-push    <- ["unit-test","eslint-check","gitleaks-scan","semgrep-scan","sca-scan"]
trivy-scan    <- ["build-push"]
upload-sbom   <- ["build-push"]
cosign-sign   <- ["trivy-scan"]
update-gitops <- ["cosign-sign"]

不要用 .spec.tasks[*].taskRef.name 盤點這條 Pipeline 用了哪些 Task。 走 cluster resolver 的 Task 沒有 taskRef.name,jsonpath 會直接跳過那一列而不是留空——輸出的筆數比實際少,而且看不出來少了誰。resolver 在 1.23.1 是主流寫法,要盤點得用 -o json 自己判斷 taskRef.nametaskRef.resolver 兩種形態。

6.2 查有沒有 onError

兩層都要查,而且查法不同。

# A. pipelineTask 層
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o json | ConvertFrom-Json |
    ForEach-Object { $_.spec.tasks } | Where-Object { $_.onError } |
    Select-Object name, onError

# B. step 層(在 Task 定義裡,不在 Pipeline 裡)
((& $OC --kubeconfig $kc get task -n ci -o json) | ConvertFrom-Json).items | ForEach-Object {
  $t = $_.metadata.name
  $_.spec.steps | Where-Object { $_.onError } | ForEach-Object { "$t / $($_.name) = $($_.onError)" } }

兩條都期望無輸出。B 不能省——step 層的 onError 寫在 Task 裡,查 Pipeline 查不到。

6.3 查實際執行的起訖時間

& $OC --kubeconfig $kc get taskrun -n ci -l tekton.dev/pipelineRun=<run-name> `
    -o custom-columns='TASK:.metadata.labels.tekton\.dev/pipelineTask,START:.status.startTime,END:.status.completionTime'

舊環境的輸出:

TASK             START                  END
build-push       2026-07-23T15:26:00Z   2026-07-23T15:26:09Z
trivy-scan       2026-07-23T15:26:09Z   2026-07-23T15:26:19Z
upload-sbom      2026-07-23T15:26:09Z   2026-07-23T15:26:15Z
cosign-sign      2026-07-23T15:26:19Z   2026-07-23T15:26:24Z
update-gitops    2026-07-23T15:26:24Z   2026-07-23T15:26:29Z

三件事一次看出來:

  • build-push 結束的那一秒(15:26:09),trivy-scanupload-sbom 同時起跑 → 該並行的有並行
  • cosign-signtrivy-scan 結束的那一秒(15:26:19)才起跑 → 沒有重疊
  • update-gitopscosign-sign 結束的那一秒(15:26:24)才起跑 → 沒有重疊

6.4 PowerShell 5.1 的老問題

{range} 語法裡如果有內層雙引號,會被 argv 解析吃掉:

error: error parsing jsonpath ... unclosed action

6.1 那段之所以外層用雙引號、內層用單引號,就是為了避開這個。內層一定要雙引號的時候,改用 -o json | ConvertFrom-Json 自己取——6.2 就是這樣寫的。


收工前檢查清單

# 1. 兩條硬邊界
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o jsonpath="{.spec.tasks[?(@.name=='cosign-sign')].runAfter}"
# 期望 ["trivy-scan"]
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o jsonpath="{.spec.tasks[?(@.name=='update-gitops')].runAfter}"
# 期望 ["cosign-sign"]

# 2. build-push 的匯流沒有漏掉任何前置
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o jsonpath="{.spec.tasks[?(@.name=='build-push')].runAfter}"

# 3A. pipelineTask 層有沒有 onError
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o json | ConvertFrom-Json |
    ForEach-Object { $_.spec.tasks } | Where-Object { $_.onError } | Select-Object name, onError

# 3B. step 層有沒有 onError(寫在 Task 裡,查 Pipeline 查不到)
((& $OC --kubeconfig $kc get task -n ci -o json) | ConvertFrom-Json).items | ForEach-Object {
  $t = $_.metadata.name
  $_.spec.steps | Where-Object { $_.onError } | ForEach-Object { "$t / $($_.name) = $($_.onError)" } }

# 3C. 執行紀錄裡有沒有 FailureIgnored(pipelineTask 層被忽略的痕跡)
((& $OC --kubeconfig $kc get taskrun -n ci -o json) | ConvertFrom-Json).items |
  Where-Object { $_.status.conditions[0].reason -eq 'FailureIgnored' } |
  ForEach-Object { $_.metadata.name }

# 3D. 「成功」的 TaskRun 裡有沒有非 0 的 step exitCode ← 抓 step 層的唯一辦法
((& $OC --kubeconfig $kc get taskrun -n ci -o json) | ConvertFrom-Json).items |
  Where-Object { $_.status.conditions[0].status -eq 'True' } | ForEach-Object {
    $n = $_.metadata.name
    $_.status.steps | Where-Object { $_.terminated.exitCode -ne 0 } |
      ForEach-Object { "$n step=$($_.name) exitCode=$($_.terminated.exitCode)" } }

# 4. 實際執行有沒有重疊
& $OC --kubeconfig $kc get taskrun -n ci -l tekton.dev/pipelineRun=<run-name> `
    -o custom-columns='TASK:.metadata.labels.tekton\.dev/pipelineTask,START:.status.startTime,END:.status.completionTime'

# 5. 失敗時的語意(換版本後要重驗)
& $OC --kubeconfig $kc get pipelinerun <run> -n ci -o jsonpath='{.status.conditions[0].message}'
# 看 Cancelled 是不是 0

第 1、3A–3D 項值得排進定期檢查——兩種改法都不會有任何錯誤,Pipeline 照跑、簽章照發、驗簽照過。

第 5 項只驗第 1 節那個語意,不要拿它當防護檢查。 前提二那個繞法之下 Cancelled 一樣是 0,這一項完全看不出異常。

3D 是抓 step 層 onError 的唯一辦法,因為那一層在 PipelineRun 與 TaskRun 的 condition 上都不留痕跡。


收尾

判斷兩個 Task 能不能並行,問法不是「它們相不相干」,而是:如果其中一個失敗,另一個已經產生的結果還算不算數?

  • trivy-scan 失敗,cosign-sign 蓋的章不算數 → 串行
  • cosign-sign 失敗,update-gitops 觸發的部署不算數 → 串行
  • cosign-sign 失敗,upload-sbom 上傳的清單仍然算數 → 並行

會需要這樣問,是因為 Tekton 失敗時只擋還沒開始的下游,不中止已經在跑的——這是 upstream v0.14.0 起的設計行為,狀態訊息裡的 Cancelled: 0PipelineRunStopping 是直接證據。

這兩條邊界換來的是「驗簽等於驗證整條鏈」,但有兩個前提:DAG 沒被改,以及關鍵 Task 沒有被標成 onError: continue

第二個前提的機制值得記清楚onError 不是改 runAfter 這條邊的語意,是改上游那次執行的分類,讓它不算失敗——於是「上游失敗就不排下游」這條規則根本沒被觸發。邊還在,只是沒有東西去撞它。

而且它寫在 step 層時,PipelineRun 與 TaskRun 兩層都乾乾淨淨,Failed: 0Succeeded,唯一的痕跡是某個 step 的 terminated.exitCode 不是 0。只檢查 runAfter 驗不到,只看 PipelineRun 的狀態訊息也驗不到。

簽章本身不記錄流程——這是 Tekton Chains 的 provenance 之後要補的洞。


參考資料

Tekton 上游

文件 用到的結論
Pipelines runAfter 的語意;retries 在其他 Task 失敗時仍會執行;onError: continue 造成 FailureIgnoredFailed: 1 (Ignored: 1),PipelineRun 整體仍為 Succeeded
PipelineRuns StoppedRunFinally 的定義——已產生的 TaskRun 正常完成、不再排程新的非 finally task。Stopping 一詞的語意來源
v0.14.0 release notes 第 1 節的依據。Task 失敗時停止排程新 Task、等已排程的完成才標記失敗;PipelineRunStopping 這個 reason 的引入
issue #3119 pipeline 含 finallyPipelineRunStopping 可能不被設定(2020 年回報,1.23.1 是否修復未查)
TEP-0058 Graceful PipelineRun Termination StoppedCancelled 兩種語意的設計討論

上一篇
Day 16:單節點的 GC 門檻與 Nexus cleanup policy
下一篇
Day 18:第一道掃描 —— gitleaks 掃的是 git 歷史
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言